import type { ARoutine } from "./ARoutine";
import { AZone } from "./AZone";
import { Agent } from "./Agent";
import { HashSet } from "./Common";
import * as Debug from "./Debug";
import { ContainerType, RoutineResult, type ActionHandler, type Descriptor, type DestinationData, type LockHandler, type ObjectHandlerKeys, type ScheduledRoutine, type StatusDescriptor, type CustomRoutineKeys } from "./Definitions";
import { ClassID, ID } from "./ID";
import { Pronoun } from "./NarrativeBase";

export class AObject {
    constructor(name: string, alias: Iterable<string>, args?: Partial<AObject>, attr?: Iterable<ID>, cls?: ClassID) {
        if (args != undefined) Object.assign(this, args);
        this.name = new ID(name, new Set<string>(alias));
        this.attribute = new HashSet<ID>(...(attr ?? []));
        this.class = cls ?? null;
    }

    name: ID;
    pronoun = Pronoun.It;

    /** @deprecated */
    indefiniteName = (detail = false) => this.name.value; // 一个（普通的）乒乓球
    /** @deprecated */
    fullyQualifiedName = (detail = false): string => {
        if (this.parentPart != null)
            return this.parentPart.fullyQualifiedName() + '的' + this.indefiniteName(detail);
        if (this.parentContainer instanceof AObject)
            return this.parentContainer.fullyQualifiedName() + (this.parentContainer.containerType == ContainerType.Surface ? '上的' : '里的') + this.indefiniteName(detail);
        // ADV_TODO
        return this.indefiniteName(detail);
    };

    class: ClassID | null;
    attribute: HashSet<ID>;
    prototype: AObject | null = null; // or 'any'?
    flags = new Set<string>();
    showInDescription = true;

    parentContainer: AObject | AZone | Agent | null = null;
    parentPart: AObject | null = null;
    holder: Agent | null = null;

    /** Contents of a container */
    contents = new Set<AObject>;
    /** Parts of an object, e.g. the drawers of a desk */
    parts = new Set<AObject>;
    // ADV_TODO: change these back to arrays, don't use sets; we can define dream flavors
    // in which the order of contents is always kept/randomized
    get parentZone(): AZone | null {
        if (this.parentPart != null) return this.parentPart.parentZone;
        if (this.parentContainer instanceof AZone
            || this.parentContainer == null) return this.parentContainer;
        if (this.holder != null) return this.holder.parentZone;
        return this.parentContainer.parentZone;
    }

    get canSeeContents(): boolean {
        return this.containerType != ContainerType.NotContainer 
            && !(this.containerType == ContainerType.Interior 
                && (this.isOpenable && !this.openState || !this.isContentsVisible));
    }

    isChildrenOf(x: AObject | AZone | Agent): boolean {
        if (this.parentPart == x || this.parentZone == x || this.parentContainer == x)
            return true;
        if (this.parentPart != null)
            return this.parentPart.isChildrenOf(x); 
        if (this.parentContainer instanceof AObject)
            return this.parentContainer.isChildrenOf(x); 
        return false;
    }

    // physics
    /** unit: cm^3 - roughly the volume of the bounding box */
    size = 0;
    /** unit: cm - diameter of the smallest circle that the object can pass through */
    diameter = -1;
    /** unit: kg - for mass including contents, use the `totalMass` getter */
    mass = 0;
    /** unit: cm^3 or mL  */
    capacity = -1; // 

    /** unit: cm - also sqrt(area) for surface containers */
    mouthSize = -1;

    // attributes
    containerType = ContainerType.NotContainer;
    isReachable = true;
    isTakable = false;
    isOpenable = false;
    isToggle = false;
    isSwitch = false;
    isHoldItem = false;
    openState = false;
    switchState = false;
    /** Defaults to true */
    isContentsVisible = true;

    // special
    writing = {
        text: '',
        /** In `wcwidth`; i.e. a wide character counts as two. */
        maxLength: 0,
        /** A non-standard medium can still be written on, as long as the maximum length
         *  is not exceeded, but you would not notice when *nothing* is written on it. */
        isStandardMedium: false,
        isInstrument: false,
        isEraser: false
    };

    isLightSource = false; // only when turned on
    isPiercingWeapon = false;
    isBluntWeapon = false;

    // door-likes
    isEnterable = false;
    isLocked = false;
    zoneTo = (self: AObject): (DestinationData | null) => null;
    linkedSide = (self: AObject): AObject | null => null;

    get totalMass() {
        let m = this.mass;
        for (let x of this.parts)
            m += x.totalMass;
        for (let x of this.contents)
            m += x.totalMass;
        return m;
    }

    /** Displayed when observing the room. */
    shortDescription: Descriptor<AObject> | null = null;
    /** Displayed when examined. */
    description: Descriptor<AObject> | null = null;
    /** Displayed every turn (only if changed) and when going in zones */
    status: StatusDescriptor<AObject> = () => null;

    customRoutines: {
        [p in CustomRoutineKeys]?: ARoutine<[self: AObject]>;
    } = {};

    beforeAction: {
        [p in ObjectHandlerKeys]?: ActionHandler<AObject>;
    } = {};

    /** Note: `Enter` in `afterAction` is executed after the agent is transfered to the
     * target zone. Important: the `afterAction` of `Listen`, `Smell` etc. will NOT be
     * called; use `beforeAction` instead! */
    afterAction: {
        [p in ObjectHandlerKeys]?: ActionHandler<AObject>;
    } = {};

    beforeAddContent?: (self: AObject, ag: Agent, obj: AObject) => ScheduledRoutine;
    afterAddContent?: (self: AObject, ag: Agent, obj: AObject) => ScheduledRoutine;
    beforeRemoveContent?: (self: AObject, ag: Agent, obj: AObject) => ScheduledRoutine;
    onBeingPut?: (self: AObject, ag: Agent, container: AObject) => ScheduledRoutine;

    onUnlock?: LockHandler<AObject>;
    onLock?: LockHandler<AObject>;

    static CloneFrom(prototype: AObject, removeHierachy = true) {
        let nobj = new AObject(prototype.name.value, prototype.name.alias, prototype);
        nobj.attribute = new HashSet<ID>(...prototype.attribute);
        nobj.prototype = prototype.prototype ?? prototype;

        let newSet = new Set<AObject>;
        for (let x of prototype.contents) {
            let n = AObject.CloneFrom(x);
            n.parentContainer = nobj;
            newSet.add(n);
        }
        nobj.contents = newSet;

        newSet = new Set<AObject>;
        for (let x of prototype.parts) {
            let n = AObject.CloneFrom(x);
            n.parentPart = nobj;
            newSet.add(n);
        }
        nobj.parts = newSet;

        if (removeHierachy) {
            nobj.parentContainer = null;
            nobj.parentPart = null;
            nobj.holder = null;
        }
        return nobj;
    }

    readonly findSubObjectInternal = (name: string) => {
        for (let x of this.parts)
            if (x.name.value == name || x.name.alias.has(name))
                return x;
        for (let x of this.contents)
            if (x.name.value == name || x.name.alias.has(name))
                return x;
        return null;
    };

    readonly addPartInternal = (...objs: AObject[]) => {
        for (let obj of objs) {
            Debug.assert(obj.parentContainer == null);
            this.parts.add(obj);
            obj.parentPart = this;
        }
    };

    readonly addContentInternal = (...objs: AObject[]) => {
        for (let obj of objs) {
            Debug.assert(obj.parentContainer == null && obj.parentPart == null);
            this.contents.add(obj);
            obj.parentContainer = this;
        }
    };

    readonly removeContentInternal = (...objs: AObject[]) => {
        for (let obj of objs) {
            Debug.assert(obj.parentContainer == this);
            this.contents.delete(obj);
            obj.parentContainer = null;
        }
    };

    readonly detachFromContainerInternal = () => {
        Debug.assert(this.parentContainer != null);
        if (this.parentContainer instanceof Agent)
            this.parentContainer.removePossessionInternal(this);
        else if (this.parentContainer instanceof AObject)
            this.parentContainer.removeContentInternal(this);
        else if (this.parentContainer instanceof AZone)
            this.parentContainer.removeObjectInternal(this);

        Debug.assert(this.parentContainer == null);
    };

    /**
     * Note: does **NOT** call before/afterRemoveContent
     */
    readonly RemoveContent = (ag: Agent, obj: AObject) => {
        if (this.containerType == ContainerType.Interior
                && this.mouthSize > 0 && obj.diameter > this.mouthSize)
            return RoutineResult.ObjectLargerThanMouthTake(
                {object: obj, container: this}, true);
        this.contents.delete(obj);
        obj.parentContainer = null;
        return RoutineResult.Continue();
    };

    /**
     * Note: requires the object be detached. Does **NOT** call before/afterAddContent.
     *
     * This is ugly but I may have to add a `simulate` parameter, since an agent must know
     * whether a container accepts their object BEFORE they take it out of their
     * inventory, which is not possible if the only time to check this is when they
     * actually try to put the object in the container, as this requires `obj.parent ==
     * null`.
     */
    readonly AddContent = (ag: Agent, obj: AObject, simulate: boolean) => {
        let v = 0;
        switch (this.containerType) {
        case ContainerType.NotContainer:
            throw new Error('adding content to NotContainer');
        case ContainerType.Surface:
            for (let x of this.contents)
                v += x.diameter ** 2;
            v += obj.diameter ** 2;
            if (v > this.mouthSize ** 2)
                return RoutineResult.ContainerTooFull(
                    {object: obj, container: this}, true);
            break;
        case ContainerType.Interior:
            for (let x of this.contents)
                v += x.size;
            v += obj.size;
            if (v > this.capacity)
                return RoutineResult.ContainerTooFull(
                    {object: obj, container: this}, true);
            if (this.mouthSize > 0 && obj.diameter > this.mouthSize)
                return RoutineResult.ObjectLargerThanMouthPut(
                    {object: obj, container: this}, true);
            break;
        }
        if (!simulate) {
            Debug.assert(obj.parentContainer == null && obj.parentPart == null);
            this.contents.add(obj);
            obj.parentContainer = this;
        }
        return null;
    };
}
